ClassLoader(类加载)
0x00 基础知识
Java源代码(.java)通过Java编译器编译成Java字节码(.class)文件,并保存在磁盘或网络等位置上。Java类加载器的主要作用是将这些字节码文件加载到内存中,并将其转化为Java类对象,供JVM调用和执行。
Java程序在计算机运行中有三个阶段:
- 代码阶段/编译阶段
- Class类阶段(加载阶段)
- Runtime运行阶段
这里解释一些名词,方便后续深入了解某些概念
- 方法区:一块用于存储类信息、常量、静态变量等数据的内存区域
- stack:在Java虚拟机中,stack(栈)是一种线程私有的内存区域,用于存储线程的方法调用和局部变量
- heap:在Java虚拟机中,heap(堆)是一块用于存放对象实例的内存区域
类加载的三个阶段:
类加载后内存布局情况如下:
一、加载(Loading)
JVM在该阶段将字节码从不同的数据源(class文件、jar包、网络
)转化为二进制字节流加载到内存中,并生成一个代表该类的java.lang.Class
对象
二、连接(Linking)
该阶段负责把类的二进制数据合并到
jre
中去(Java运行时环境)
验证:确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全
(补充一下:为了方便虚拟机识别一个文件是否是class类型的文件。规定每个class文件都必须以四个字节作为开始,这个数字就是魔数)
准备:JVM会在该阶段对静态变量,分配内存并初始化。这些变量所使用的内存都将在方法区中进行分配
各数据类型的默认初始值如下:
解析:虚拟机将常量池内的符号引用(常量名)替换为直接引用(地址)的过程
符号引用:
指的是在代码中使用的名称,比如方法名、字段名等
直接引用:
指实际的内存地址
这里需要注意一下加载和连接阶段都是由JVM来控制的
三、初始化(initialization)
在Java中,类被创建时需要初始化,初始化阶段是类生命周期的最后一个阶段,主要是为变量赋初值,执行静态代码块中的代码,初始化父类,执行实例变量的赋值操作以及执行构造方法来完成类的初始化操作
类初始化又分为被动初始化和主动初始化两种情况:
被动初始化:若只是使用类的成员变量,不涉及到调用类的构造方法或者静态方法时,JVM不会对类进行初始化,称之为被动初始化。
主动初始化:当进行如下任何一种操作时,JVM便会对类进行主动初始化
创建类的实例;
访问类或者接口的静态变量,或者为静态变量赋值;
调用类或接口的静态方法;
反射某个类;
初始化某个子类;
启动类(包含 main 方法的类)。
需注意的是类初始化只会进行一次,类初始化的结果会存储在方法区(JDK8之前)或元空间(JDK8及之后)中,随后所有对象共享这些结果。
主动初始化:
1 | public class InitExample { |
结果如下:
创建实例initExample
时会触发类的初始化,因此静态代码块会被执行。当再调用incrementCounter
时,需要访问类的静态成员变量 counter
,由于 counter
已经准备好,具备了初始化条件,静态代码块就不会再次执行,而是直接使用准备好的 counter
变量值。这也表明了静态代码块只会在类初始化阶段执行一次
被动初始化:
1 | public static void main(String[] args) { |
结果如下:
静态方法incrementCounter
会直接使用静态变量counter,不需要创建类的对象,因此不会触发类的初始化。
0x01 JVM三种类加载器
- 启动类加载器(Bootstrap Classloader)也叫引导类加载器:它是最顶层的类加载器,负责加载Java的核心类库,如
rt.jar
等,由C++语言实现。 - 扩展类加载器(Extension Classloader):它负责加载扩展目录下(
JAVA_HOME/jre/lib/ext
)的jar包,在JVM启动时创建,由Java语言实现。 - 应用程序类加载器(Application Classloader)也叫系统类加载器:它负责加载应用程序类路径下的类,包括自己写的代码和第三方jar包等,是最常用的类加载器,也是默认的类加载器。
除了上述java自带提供的类加载器外,还可以通过继承java.lang.ClassLoader类的方式实现自己的类加载器,即自定义类加载器(UserDefineClassLoader)
举个例子来理解一下JVM三种类加载器之间的关系以及各自的作用:
假设编写一个Java程序,并且程序需要使用一个名为com.example.MyClass
的自定义类。当程序启动时,JVM将首先使用启动类加载器查找所有位于JVM启动路径(bootstrap classpath)的类文件,其中可能包括JVM本身的类和库。
若启动类加载器找不到com.example.MyClass
,则它会将类请求传递给扩展类加载器。扩展类加载器负责查找位于JVM扩展路径(extension classpath)中的类。如果它找到了com.example.MyClass
,它将把类加载到JVM中并返回给启动类加载器。 如果扩展类加载器仍然找不到com.example.MyClass
,则应用程序类加载器会被调用。应用程序类加载器负责查找位于应用程序类路径(classpath)中的类,这些类是应用程序自己的类和库。如果应用程序类加载器找到了com.example.MyClass
,它将类加载到JVM中并返回给扩展类加载器,最终返回给启动类加载器。
这里其实就涉及到了双亲委派机制
0x02 双亲委派机制
通常情况下,JVM默认三种类加载器会相互配合使用,且是按需加载方式,加载过程使用的是双亲委派模式,即把需要加载的类交由父加载器进行处理。
双亲委派机制:是JVM中的一种类加载器工作模式。通过父子关系,将类加载请求委派给父类加载器先尝试加载,如果父类加载器无法加载,则再由子类加载器进行加载。
具体来说,当一个类需要被加载时,双亲委派机制会首先将这个任务委派给它的父类加载器。若父类加载器无法完成这个加载任务,它会继续将任务向上委托给它的父类加载器,直到顶层的启动类加载器。若启动类加载器无法完成这个任务,它会将任务退回给子类加载器,再依次尝试查找。如果所有的类加载器都无法找到所需的类,系统会抛出 ClassNotFoundException
异常。
双亲委派机制的作用:
- 这种机制有效地避免了同名类的冲突,并防止了恶意代码的注入
- Java程序可以确保每个类都只被加载一次,并且尽可能地保证类的唯一性和安全性。
下面通过代码来理解一下:
1 | class ClassLoaderTest { |
通过输出结果,可以看到类加载器之间的父子关系,以及双亲委派机制的工作过程。
应用程序类加载器首先尝试加载 java.lang.System
类,但发现自己无法加载这个类。于是,它将加载任务委派给它的父类加载器,即系统类加载器,系统类加载器又将任务委派给扩展类加载器,扩展类加载器最终将任务委派给启动类加载器。由于启动类加载器能够找到 java.lang.System
类,所以它加载了这个类,系统类加载器、扩展类加载器和应用程序类加载器不需要参与类加载过程。最终, java.lang.System
类被应用程序类加载器加载成功,并打印出了它的类加载器。
这里解释一下启动类加载器结果为null:
(因为启动类加载器是由Java虚拟机的实现所提供的,并不是一个普通的Java类,也不是由Java代码实现的,所以没有相应的Java类对象。启动类加载器的实现是由C++编写的,它是JVM的一部分,并不受Java代码控制。因此,当在Java代码中使用getClass().getClassLoader()方法获取一个类的类加载器时,如果这个类是由启动类加载器加载的,那么返回值就会是null。)
0x03 CLassLoader类核心方法
findClass(String name)
- 根据指定名称查找类文件并返回相应的Class对象
- 该方法首先会检查当前ClassLoader是否能够加载该类,如果不能加载则会沿着ClassLoader的层次结构向上查找,直到找到一个能够加载该类的ClassLoader,然后返回该ClassLoader所加载的类。
loadClass(String name)
- 加载指定名称的类
- 该方法是主要的类加载方法,尝试加载并链接指定名称的类。它会先检查当前ClassLoader是否能够加载该类,如果不能加载则会沿着ClassLoader的层次结构向上查找,直到找到一个能够加载该类的ClassLoader,然后返回该ClassLoader所加载的类。如果所有的ClassLoader都没有找到该类,则会抛出ClassNotFoundException异常。
getResource(String name)
- 在类路径下查找指定名称的资源文件并返回URL对象
- 该方法首先会检查当前ClassLoader的类路径下是否包含指定名称的资源文件,如果找到了则返回相应的URL对象
getResourceAsStream(String name)
- 在类路径下查找指定名称的资源文件并返回InputStream对象
- 该方法首先会检查当前ClassLoader的类路径下是否包含指定名称的资源文件,如果找到了则返回相应的InputStream对象。
getParent()
- 返回该ClassLoader的父ClassLoader
- 若当前ClassLoader没有父ClassLoader,则返回null
defineClass(String name, byte[] b, int off, int len)
- 将字节数组转换成一个Class对象
- 该方法首先将字节数组转换成一个Java类文件的内存映像,然后再用该内存映像创建一个Class对象。该方法主要用于动态加载类,比如在Java程序运行时根据用户输入的数据或者其他条件获取类定义,并动态地将该类加载到Java虚拟机中来。
0x04 类加载隔离
在JVM中存在多个类加载器,每个类加载器都负责从不同的资源(例如文件系统、网络、JAR文件等)中去加载类。在Java应用程序中,通常使用系统类加载器来加载类。
当在同一个JVM中运行多个Java应用程序时,如果它们使用了相同的类库,就会发生类库冲突。例如,如果两个应用程序都使用了不同版本的某个类库,则可能会发生不兼容性或冲突问题,导致应用程序崩溃。
而类加载隔离机制可以有效解决这个问题。类加载隔离机制在不同的应用程序之间提供了完全独立的类加载环境,每个应用程序都有自己的类加载器和类库,互相之间不会干扰。
具体实现方法:
为每个应用程序创建一个独立的类路径,并为该应用程序使用单独的类加载器。类加载器可以从不同的资源中加载类,每个应用程序的类库互相之间是独立的。这样,不同应用程序间的类库冲突问题可以得到避免。
通过一个例子来具体理解下:
应用程序使用了Apache工具包中的commons-lang3库。插件程序同样使用了commons-lang3库,但是是不同版本的库。
在此情况下,如果不使用类加载隔离机制,那么应用程序和插件程序就会产生类库冲突,导致应用程序崩溃。 为了避免这种冲突,可以使用类加载隔离机制。
1 | public class AppClassLoader extends URLClassLoader { |
如代码所示,AppClassLoader
和PluginClassLoader
分别用来加载应用程序和插件程序中的类。两个类加载器分别使用了不同的commons-lang3库,保证了它们之间的类库互相独立,避免了类库冲突问题。
0x05 自定义类加载器过程
为什么要了解自定义类加载的过程?
利用自定义ClassLoader类,可以将恶意代码注入到应用程序中,然后通过ClassLoader来加载并执行恶意代码。因此,理解自定义ClassLoader类的原理和实现方法对于Java安全实践非常重要
自定义类加载器步骤如下:
继承ClassLoader类
- 自定义类加载器必须继承ClassLoader类,这样才能够使用JVM提供的类加载机制。
覆盖findClass()方法
- findClass()方法负责从指定路径或文件中加载类文件。在实现findClass()方法时,通常需要重写其逻辑,来达到自定义需求。
实现defineClass()方法
- defineClass()方法实现类的定义功能。在自定义类加载器中,可以通过覆盖defineClass()方法来实现自己的类定义逻辑。
调用loadClass()方法
- 自定义类加载器中,还需要调用loadClass()方法来实现类的加载。
确定类加载路径
- 自定义类加载器还需要确定类的加载路径。这个路径可以是文件系统路径、网络路径或者其他自定义的路径。
加载类文件
- 自定义类加载器把类文件加载进内存中,这个过程可以通过读取类文件的字节流来实现。
下面通过一个例子来具体了解一下自定义类加载器过程:
先自定义一个MyClass类(这个类的字节码文件通常位于classpath或某个jar包里,接下来要通过自定义类加载器来加载这个类)
1 | public class MyClass { |
创建一个自定义类加载器 MyClassLoader
,它继承了Java标准库中的ClassLoader类。在这个自定义类加载器中,重写了 findClass()
方法,尝试将类的字节码从文件中读取出来,并通过 defineClass()
方法来定义这个类。而这个字节码则是通过 getClassBytes()
方法来获取的,它会将类的名字转换为路径形式并读取相应的文件。
1 | public class MyClassLoader extends ClassLoader { |
最后使用自定义类加载器来加载 MyClass
类。首先创建一个自定义类加载器 classLoader
,然后调用 loadClass()
方法来加载 MyClass
类。再使用反射创建MyClass类实例,并调用它的sayHello()方法。
0x06 参考博客
@[攻击Java Web应用-Java Web安全] (javasec.org)
@韩顺平